【Unity URP】卡通渲染中的刘海投影·改

Posted by FlowingCrescent on 2021-10-03
Estimated Reading Time 8 Minutes
Words 1.8k In Total
Viewed Times

前言

距离上一篇关于刘海投影的文章也过了一年有余https://zhuanlan.zhihu.com/p/232450616,笔者也在这期间积累了一些工作经验,于是在实习结束后的某一天灵感迸现,发现刘海投影应有更加简单且高效的做法。

效果实现原理

以模板测试为核心,原理变得更为简单了:
在绘制面部时写入特定的模板值X,然后在不透明物体绘制完之后再绘制一次头发,此时根据屏幕空间的光照方向对它的裁剪空间坐标进行偏移,并只在模板值X时通过模板测试。

不熟悉模板测试的读者可以参考以下文章:
https://zhuanlan.zhihu.com/p/28506264

本文目录为:

  1. 使用Render Feature额外绘制头发
  2. 改良-以性能换效果
  3. 结语

笔者所用Unity版本为2019.4.6f1,URP 7.3.1
笔者经验甚少,才浅学疏,难以避免文中出现错误,还请大家不吝斧正,只求轻喷。

使用Render Feature额外绘制头发

我们要先在画脸的时候写入Stencil Buffer,因此角色Shader中需添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 Properties
{
//......

[Header(Stencil)]
_StencilRef ("_StencilRef", Range(0, 255)) = 0
[Enum(UnityEngine.Rendering.CompareFunction)]_StencilComp ("_StencilComp", Float) = 0
}

//......

Pass
{
Name "BaseCel"
Tags { "LightMode" = "UniversalForward" }

Stencil
{
Ref [_StencilRef]
Comp [_StencilComp]
Pass replace
}

//......

image.png
然后面部材质球面板中如此设置,意为面部必然通过模板测试,且会将模板值改为128
128这数字是笔者随便挑的,没什么特殊含义。

之后便添加一个RenderFeature,专门用于绘制头发
关于一些细节的基础内容笔者已在上一篇文章中阐述过,这次就直接贴代码,不过多解释了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class CelHairShadow_Stencil : ScriptableRendererFeature
{
[System.Serializable]
public class Setting
{
public Color hairShadowColor;
[Range(0, 0.1f)]
public float offset = 0.02f;
[Range(0, 255)]
public int stencilReference = 1;
public CompareFunction stencilComparison;

public RenderPassEvent passEvent = RenderPassEvent.BeforeRenderingTransparents;
public LayerMask hairLayer;
[Range(1000, 5000)]
public int queueMin = 2000;

[Range(1000, 5000)]
public int queueMax = 3000;
public Material material;

}
public Setting setting = new Setting();
class CustomRenderPass : ScriptableRenderPass
{
public ShaderTagId shaderTag = new ShaderTagId("UniversalForward");
public Setting setting;

FilteringSettings filtering;
public CustomRenderPass(Setting setting)
{
this.setting = setting;

RenderQueueRange queue = new RenderQueueRange();
queue.lowerBound = Mathf.Min(setting.queueMax, setting.queueMin);
queue.upperBound = Mathf.Max(setting.queueMax, setting.queueMin);
filtering = new FilteringSettings(queue, setting.hairLayer);
}
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
setting.material.SetColor("_Color", setting.hairShadowColor);
setting.material.SetInt("_StencilRef", setting.stencilReference);
setting.material.SetInt("_StencilComp", (int)setting.stencilComparison);
setting.material.SetFloat("_Offset", setting.offset);
}

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
var draw = CreateDrawingSettings(shaderTag, ref renderingData, renderingData.cameraData.defaultOpaqueSortFlags);
draw.overrideMaterial = setting.material;
draw.overrideMaterialPassIndex = 0;

//获取主光源方向,并转换到相机空间
var visibleLight = renderingData.cullResults.visibleLights[0];
Matrix4x4 worldToScreen = renderingData.cameraData.camera.worldToCameraMatrix;
Vector2 lightDirSS = renderingData.cameraData.camera.worldToCameraMatrix * (visibleLight.localToWorldMatrix.GetColumn(2));
setting.material.SetVector("_LightDirSS", lightDirSS);

CommandBuffer cmd = CommandBufferPool.Get("DrawHairShadow");
context.ExecuteCommandBuffer(cmd);
context.DrawRenderers(renderingData.cullResults, ref draw, ref filtering);
}

public override void FrameCleanup(CommandBuffer cmd)
{

}
}

CustomRenderPass m_ScriptablePass;

public override void Create()
{
m_ScriptablePass = new CustomRenderPass(setting);

m_ScriptablePass.renderPassEvent = setting.passEvent;
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (setting.material != null)
renderer.EnqueuePass(m_ScriptablePass);
}
}



image.png
在外面的面板中如此设置即可,当然头发还是得设置一下Layer为Hair

之后便是指定Material,使用这个Shader即可,可见内容相当简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
Shader "Custom/HairShadow"
{
Properties
{
_Color ("Color", Color) = (1, 1, 1, 1)
_Offset ("Offset", float) = 0.02
[Header(Stencil)]
_StencilRef ("_StencilRef", Range(0, 255)) = 0
[Enum(UnityEngine.Rendering.CompareFunction)]_StencilComp ("_StencilComp", float) = 0
}
SubShader
{
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalRenderPipeline" }

HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

CBUFFER_START(UnityPerMaterial)
float4 _Color;
float _Offset;
float4 _LightDirSS;
CBUFFER_END
ENDHLSL

Pass
{
Name "HairShadow"
Tags { "LightMode" = "UniversalForward" }

Stencil
{
Ref [_StencilRef]
Comp [_StencilComp]
Pass keep
}

ZTest LEqual
ZWrite Off

HLSLPROGRAM

#pragma vertex vert
#pragma fragment frag

struct a2v
{
float4 positionOS: POSITION;
float4 color: COLOR;
};

struct v2f
{
float4 positionCS: SV_POSITION;
float4 color: COLOR;
};


v2f vert(a2v v)
{
v2f o;

VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz);
o.positionCS = positionInputs.positionCS;

float2 lightOffset = normalize(_LightDirSS.xy);
//乘以_ProjectionParams.x是考虑裁剪空间y轴是否因为DX与OpenGL的差异而被翻转
//参照https://docs.unity3d.com/Manual/SL-PlatformDifferences.html
//"Similar to Texture coordinates, the clip space coordinates differ between Direct3D-like and OpenGL-like platforms"
lightOffset.y = lightOffset.y * _ProjectionParams.x;
o.positionCS.xy += lightOffset * _Offset;

o.color = v.color;
return o;
}

half4 frag(v2f i): SV_Target
{
return _Color;
}
ENDHLSL

}
}
}

于是我们就可以获得一个面部有刘海投影的效果了
image.png
image.png
image.png

由于自带的深度测试,直接避免了上一篇文章中的许多问题。

这样做的优缺点也比较显然:
优点

  1. 规避了额外绘制Buffer,无需切换RT
  2. 精度与使用RT写入深度进行深度判断相比更加高

缺点

  1. “阴影”的绘制与光照着色及人物贴图完全无关,导致在许多情况下会显得突兀

那么笔者也截几个图给大家看看这个缺点比较明显的时候
image.png
image.png
说白了就是因为刘海投影只使用了一个暗色,而面部是用面部贴图乘以暗部颜色的,只要面部贴图不是赛璐璐风格的纯色,便无法避免两者在结果上的差异。

改良-以性能换效果

问题不就是出在咱们没有本来的贴图颜色吗,那大不了再画一次脸,这次直接就是贴图色乘以暗色,总没问题了吧。
那么我们将头发的Pass修改一下,使用ColorMask 0让它不再画入颜色,且将模板值重置为0

1
2
3
4
5
6
7
8
9
10
Stencil
{
Ref [_StencilRef]
Comp [_StencilComp]
Pass Zero
}

ZTest LEqual
ZWrite Off
ColorMask 0

于是我们给这个Shader再添加一个Pass,用于重新渲染面部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
Pass
{
Name "HairShadow_Face"
Tags { "LightMode" = "UniversalForward" }

Stencil
{
Ref 0
Comp [_StencilComp]
Pass keep
}

ZTest LEqual
ZWrite Off

HLSLPROGRAM

#pragma vertex vert
#pragma fragment frag

struct a2v
{
float4 positionOS: POSITION;
float4 color: COLOR;
float2 uv: TEXCOORD;
};

struct v2f
{
float4 positionCS: SV_POSITION;
float4 color: COLOR;
float2 uv: TEXCOORD0;
};


v2f vert(a2v v)
{
v2f o;

VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz);
o.positionCS = positionInputs.positionCS;

o.color = v.color;
o.uv = v.uv;
return o;
}

TEXTURE2D(_FaceTex);
SAMPLER(sampler_FaceTex);

half4 frag(v2f i): SV_Target
{
return SAMPLE_TEXTURE2D(_FaceTex, sampler_FaceTex, i.uv) * _Color;
}
ENDHLSL

}

然后在RenderFeature中增加面部贴图的指定,以及面部的再绘制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Setting
{
//......
public Texture faceTex;
public LayerMask faceLayer;

}

//......
class CustomRenderPass : ScriptableRenderPass
{
FilteringSettings filtering2;
public CustomRenderPass(Setting setting)
{
//......
filtering2 = new FilteringSettings(queue, setting.faceLayer);
}


public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
//......
setting.material.SetTexture("_FaceTex", setting.faceTex);
draw.overrideMaterialPassIndex = 1;
context.DrawRenderers(renderingData.cullResults, ref draw, ref filtering2);
}
}

于是现在,我们终于能获得一个相对理想的结果了
image.png
当然,这样结果比较理想也只是因为面部的渲染算法比较简单,如果还有边缘光或者其他什么操作,大概就得真的重新用角色材质重新画一次了。

那么最后放个效果视频

结语

模板测试的存在感比较低,大家似乎都不易想到用它来实现这个Trick,而之前需要额外绘制RT的方法如今优化后应当也相对容易落地了些。
希望本文能够给在卡通渲染领域耕耘的人们带来些许启发。

参考资料
俊虎:Unity ShaderLab 模板缓存(Stencil Buffer) 基本概念
Writing shaders for different graphics APIs


感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。